Skip to content

Release the SIGCHLD watch when the last process event goes - #217

Merged
EdmondDantes merged 2 commits into
mainfrom
fix/216-sigchld-watch-outlives-process-events
Aug 4, 2026
Merged

Release the SIGCHLD watch when the last process event goes#217
EdmondDantes merged 2 commits into
mainfrom
fix/216-sigchld-watch-outlives-process-events

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

Closes #216.

A worker drains its queue, prints its final stats and then hangs until killed. Under gdb the main thread sits in uv__io_poll with timeout=-1 from libuv_reactor_execute(no_wait=false); a uv_walk dump at that point shows active_event_count down to 1 and one live handle — signal, signum=17. No child processes exist, live or zombie.

Two causes, both in this file

libuv_process_event_dispose() empties the table behind the teardown's back. It deletes the entry from ASYNC_G(process_events) directly — the line guarding against a stale hash pointer — while the SIGCHLD teardown lives only in libuv_remove_process_event(). An event released while already stopped leaves the table empty and the handler armed. The teardown is now libuv_release_process_watch(), called from both paths.

A hidden timer still pins the loop. pool_start_healthcheck_timer() marks its timer HIDDEN precisely so an idle pool cannot block a graceful shutdown, but that flag only keeps the event out of active_event_count. uv_timer_start leaves the handle referenced, so uv_run never returns. The thread-notify handles in this file already work around it with a manual uv_unref; timers now do the same.

Measurements

Stand: a Laravel queue worker on thrun threads, 4 threads, jobs that touch the database.

build hangs
unchanged 8 of 8 with the pool healthcheck on, 1-2 of 8 with it off
timer unref only 1 of 8
both 0 of 30

Removing either fix from the final build brings the hang back on the first or second run.

Suite: 1126 pass, the same 3 failures as baseline (curl/063, curl/064, io/082) — they fail on an unpatched binary too.

What is deliberately not here

An earlier draft settled the event on ECHILD in libuv_handle_process_events(), on the theory that a child reaped by proc_close()/pclose() leaves the event waiting forever. That branch corrupts exit codes: a notified event stays in the table until its waiter resumes, so a second sweep before that sees ECHILD for the child the first sweep just reaped and overwrites exit_code with -1. Two children exiting close together while the scheduler runs PHP code — an ordinary shape — turned proc_close() results of 7 and 9 into -1 and -1, deterministically. A comment now records why that branch must not come back.

A draft also polled processes from libuv_reactor_execute() before the blocking uv_run. Measured unnecessary once the teardown was fixed — 30 clean runs without it — and removed rather than left in the reactor's hot path.

Known and unchanged

libuv_handle_process_events() reads the pid out of a copied event pointer before checking that the entry is still in the table. Unreachable today: in scheduler context the notify callbacks only enqueue coroutines, no PHP code runs inline, and every copied event holds two references for the duration of the sweep. It would become reachable if the sweep ever ran outside scheduler context and one waker held two process events. Left as it was — the minimal hardening would be to copy hash keys instead of event pointers.

libuv_process_event_dispose() deleted its entry from process_events directly,
while the teardown lives in libuv_remove_process_event(). An event released
while already stopped therefore emptied the table and left the handler armed:
nothing raises SIGCHLD again, yet the handler pins the loop, so a worker with
no work left never exits. The teardown is now its own function, called from
both paths.

A hidden timer had the same effect for a different reason. HIDDEN keeps a pool
healthcheck out of active_event_count, but uv_timer_start leaves the handle
referenced and uv_run does not return. Hidden timers now drop that reference,
as the thread-notify handles already did by hand.
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Restores the two comments the extraction displaced, and cuts the rest to what
the code does not already say.
@EdmondDantes

Copy link
Copy Markdown
Contributor Author

Уточнение по второму фиксу, после разбора механики libuv.

uv_unref() не останавливает таймер — он снимает handle со счёта тех, кто удерживает цикл. Таймер продолжает срабатывать, пока цикл крутится по другим причинам; молчит он ровно тогда, когда в процессе не осталось ни одного другого активного I/O, то есть когда процесс и так собирается завершиться. Это и есть смысл HIDDEN.

Замер на одинаковой работе — 200 job'ов, 4 потока, healthcheck пула с интервалом 30:

сборка время job'ов тиков healthcheck
без uv_unref 60 000 мс (висел, убит) 200 7856
с uv_unref 1044 мс (вышел сам) 200 31

31 тик за секунду работы — примерно раз в 32 мс, то есть таймер тикает как задумано. У сборки без фикса тиков больше только потому, что она молотила ещё 59 секунд после того, как работа кончилась.

Попутно: из трёх скрытых таймеров в дереве (pool.c:695, channel.c:305, fs_watcher.c:248) периодический только у пула. Каналы сегодня добиваются того же поведения обходным путём — регистрацией в deadlock_channels и оптовым закрытием, — а fs_watcher спасает короткая жизнь таймера. После этого фикса все трое получают нужное поведение от самого флага, и обход у каналов со временем можно снять.

Отдельно от этого PR: config/async.php в laravel-spawn объявляет healthcheck_interval в секундах, а PDO::ATTR_POOL_HEALTHCHECK_INTERVAL и pool.c меряют миллисекунды — uv_timer_get_repeat на живом таймере вернул 30. То есть пул проверяет соединения в тысячу раз чаще задуманного. Заведу отдельно.

@EdmondDantes
EdmondDantes merged commit a60f5d5 into main Aug 4, 2026
9 checks passed
@EdmondDantes
EdmondDantes deleted the fix/216-sigchld-watch-outlives-process-events branch August 4, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A finished worker never exits: the SIGCHLD watch outlives the last process event

1 participant